Internationalization (i18n
) is a crucial aspect of building any multilingual application. While modern frameworks like Next.js
offer out-of-the-box solutions—such as next-intl
—that provide type-safe translation handling, many projects still operate without these benefits.
If you're working with Next.js
, check out this guide: Type-Safe i18n in Next.js: A Complete Guide.
In this article, we’ll focus on those projects that can’t easily adopt a plug-and-play solution and need to implement internationalization logic manually.
Directory Structure
Let’s assume you have a list of features in your app, each with its own set of translations. A common approach is to organize all translation files under a single directory.
Two widely used directory structures are:
-
translations/[feature]/[locale].yml
(or.json
) -
translations/[locale]/[feature].yml
(or.json
)
For this article, we’ll go with the following assumptions (though the concepts apply broadly):
-
Translation files are written in
.yml
. -
Each
.yml
file starts with a root key that matches the feature name.
Here’s an example of what the translation file for the user
feature might look like:
user:
addUser: Add user
editUser: Edit user
messages:
userAdded: user {fullName} added
userEdited: user {fullName} edited
The Solution
To introduce type-safety into your translation system, you’ll need to follow a few key steps:
-
Generate Type Declarations: Write a script that reads the default locale’s translation files and automatically generates
TypeScript
type definitions based on their structure. -
Add a Build Script: Add this script to your
package.json
as a command—let’s call itintl:types
. -
Ensure Pre-Build Execution: Make sure
intl:types
runs before your main build process to keep the generated types up-to-date. -
Type the Translation Function: Either override the type definition of your existing translation function (commonly named
t
) or create a new, typed version of it.
Generate Type Declarations
The following script, written in JavaScript
, generates TypeScript
declaration files from your translation files.
It assumes the translation folder follows the structure: translations/[feature]/en-us.yml
, where en-us
is the default locale.
Once the translations are parsed, the generated type definitions are saved to /types/intl.d.ts
. Since this file is auto-generated, there's no need to include it in version control—make sure to add it to your .gitignore
.
const glob = require('glob');
const yaml = require('js-yaml');
const { readFileSync, writeFileSync } = require('fs');
const { parse: parseMessage, TYPE } = require('intl-messageformat-parser');
function flattenFormatElements(arr) {
const children = arr
.map((item) => flattenFormatElements(item.children ?? []))
.flat();
return [...arr, ...children];
}
function extractIcuArguments(translation) {
const args = {};
try {
const ast = parseMessage(translation);
const hasHtml = ast.some((item) => item.children);
const walk = (nodes) => {
for (const el of nodes) {
switch (el.type) {
case TYPE.literal:
// plain text, ignore
break;
case TYPE.argument:
// simple argument: {name}
args[el.value] = 'string';
break;
case TYPE.select:
// select argument: {gender, select, ...}
args[el.value] = 'string';
// walk options recursively
Object.values(el.options).forEach((opt) => walk(opt.value));
break;
case TYPE.plural:
case TYPE.number:
// plural argument: {count, plural, ...}
args[el.value] = 'number';
// walk options recursively
Object.values(el.options).forEach((opt) => walk(opt.value));
break;
default:
// For completeness, handle nested messageFormatPattern arrays
if (el.value && Array.isArray(el.value)) {
walk(el.value);
}
break;
}
}
};
walk(flattenFormatElements(ast));
if (hasHtml) args['htmlSafe?'] = 'boolean';
} catch (err) {
// fallback: assume no tokens
}
return args;
}
const allTranslations = {};
function flattenWithValues(obj, prefix = '') {
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const val = obj[key];
if (typeof val === 'string') {
allTranslations[fullKey] = val;
} else if (typeof val === 'object' && val !== null) {
flattenWithValues(val, fullKey);
}
}
}
const translationFiles = glob.sync('translations/**/en-us.yml');
for (const file of translationFiles) {
const content = readFileSync(file, 'utf8');
const parsed = yaml.load(content);
if (parsed) flattenWithValues(parsed);
}
const allKeys = new Set(Object.keys(allTranslations));
const lines = [
`// Auto-generated from YAML translations`,
`export interface IntlMessages {`,
...Array.from(allKeys)
.sort()
.map((key) => {
const message = allTranslations[key]; // store flattened key → value map earlier
const args =
typeof message === 'string' ? extractIcuArguments(message) : {};
const type =
Object.keys(args).length === 0
? 'string'
: `{ ${Object.entries(args)
.map(([k, v]) => `${k}: ${v}`)
.join('; ')} }`;
return ` '${key}': ${type};`;
}),
`}`,
``,
];
writeFileSync('types/intl.d.ts', lines.join('\n'), 'utf8');
console.log('✅ types/intl.d.ts generated.');
Update package.json
Next, we need to add a script to the package.json
to run the type-generation script. Ideally, this script should run before key operations like build
, start
, or test
. Your scripts
section might look something like this:
{
"scripts": {
...,
"intl:types": "node scripts/generate-intl-type-declarations.js",
"start": "npm intl:types && ...",
"build": "npm intl:types && ...",
"test": "npm intl:types && ...",
}
}
However, simply running intl:types
before start
isn’t always enough—especially in development. If your server is already running and you update a translation file, the type definitions won’t automatically update. To fix this, it’s important to watch translation files and regenerate type definitions whenever they change.
You have a few options for this:
-
Use tools like
chokidar
orconcurrently
to watch the translation files and rerun the script on change. - If your bundler supports customization, you can:
For example, here’s a plugin for Broccoli
(used by Ember.js
) that integrates this behavior.
const Plugin = require('broccoli-plugin');
const { execSync } = require('child_process');
module.exports = class GenerateIntlTypesPlugin extends Plugin {
constructor(inputNodes, options = {}) {
super(inputNodes, {
name: 'GenerateIntlTypesPlugin',
annotation: 'Generate intl.d.ts from translation YAML files',
persistentOutput: true,
needsCache: false,
});
this.options = options;
}
build() {
try {
execSync('npm run intl:types', { stdio: 'inherit' });
} catch (err) {
console.error('❌ Failed to generate intl.d.ts:', err);
}
}
};
And in your ember-cli-build.js
, you would configure it like this:
const mergeTrees = require('broccoli-merge-trees');
const GenerateIntlTypesPlugin = require('./scripts/generate-intl-types-plugin');
module.exports = function (defaults) {
const app = new EmberApp(...); // Your confic and params
const intlTypes = new GenerateIntlTypesPlugin([watchedTranslations]);
return mergeTrees([app.toTree(), intlTypes], { overwrite: true });
};
Define the t
Method Type
Now it's time to define a type for the t
(translate) method.
import type { IntlMessages } from './intl'; // refers to 'types/intl.d.ts'
type TMethod = <K extends keyof IntlMessages>(
key: K,
...args: IntlMessages[K] extends string ? [] : [IntlMessages[K]]
) => string
Depending on your setup, you may:
- Use this type to define a new translation function.
-
Override an existing
t
method and enhance it with type safety.
Either way, this ensures all translation usages are type-checked, helping you catch missing or incorrect keys at compile time rather than at runtime.